#  -*- coding: utf-8 -*-
# standard
import base64
from datetime import datetime
from datetime import timedelta
from datetime import date
import socket
import os
import importlib, inspect
import decimal 
from decimal import Decimal
import struct
import warnings
import multiprocessing
from multiprocessing.shared_memory import SharedMemory
import re
import sys
import math
import platform
import pathlib
import calendar
from hashlib import md5
from typing import Callable, List
from typing import Tuple
from typing import Union
from typing import Dict
import shutil
from base64 import b64decode
from functools import partial
import secrets

# third
import mistune # pip install mistune 高性能markdown解析器
from fastapi import Request, FastAPI
import psutil
import loguru
from pydantic import validate_call


# local
_P_PATH = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
if _P_PATH not in sys.path:
    sys.path.append(_P_PATH)


"""
@Title:  工具箱
@File: common_tools.py
@Author: walle 2022年08日18日 10时09分10秒
@Version: 1.0.0
@Desc: 
"""


def get_module_title(module: object) -> str:
    """
    获取模块标题
    :param module: _description_
    :return: _description_
    """
    for line in inspect.getsourcelines(module)[0]:
        line = line.strip()
        if line.startswith("@Title:"):
            parts = line.split(":", 1)
            return parts[-1]


def import_all(package: str, app: FastAPI | None = None, ignore: List[str] = None):
    """
    导入包下的所有文件
    :param package: 包名，一般是文件夹名，如 views
    :param app: FastAPI 实例  只有在导入视图的时候才需要传入
    :param ignore: 忽略的文件名列表
    """
    ignore = ignore if ignore else ['__init__.py']
    # 检出文件夹下的所有文件
    b1 = datetime.now()
    package_path = os.path.join(R_P_PATH, package)
    # 开始导入
    for file in os.listdir(package_path):
        if file.endswith(".py") and file not in ignore:
            module_name = file[:-3]
            b = datetime.now()
            module = importlib.import_module(f".{module_name}", package=package)
            if app:  # 如果传入了 app, 那么就加载路由
                router = getattr(module, "router", None)
                if router:
                    app.include_router(router=router)
            e = datetime.now()
            module_title = get_module_title(module)
            logger.debug(f"加载 {module_title} 耗时: {(e - b).total_seconds()} 秒")
    e1 = datetime.now()
    logger.debug(f"视图完成! 导入总耗时: {(e1 - b1).total_seconds()} 秒")


def get_fastapi_real_ip(request: Request) -> str:
    """
    检测fastapi客户端ip地址
    :param request:
    :return:
    """
    # 如果是nginx转发的,那就有 X-Forwarded-For 和 x-real-ip
    if "X-Forwarded-For" in request.headers:
        logger.debug(f'nginx转发: {request.headers["X-Forwarded-For"]}')
        ip = request.headers["X-Forwarded-For"].split(",")[0]
    else:
        logger.debug(f'非nginx转发: {request.client.host}')
        ip = request.client.host  # 如果没有使用nginx,那么这个地址就是准确的.
    return ip



def is_old_python(major: int = 3, minor: int = 8) -> bool:
    """
    判断当前运行的python版本是否大于等于 指定的版本, 默认小于3.8的都是旧版本
    :return: _description_
    """
    current_version = platform.python_version_tuple()
    current_major = int(current_version[0])
    current_minor = int(current_version[1])
    if current_major >= major and current_minor >= minor:
        return False
    else:
        return True
    

def real_project_path() -> str:
    """返回真实的项目目录,即使在程序冻结的状态下"""
    if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'):
        # 其实 getattr(sys, 'frozen', False) 为真就可以判断出来是冻结状态, hasattr(sys, '_MEIPASS')为了判断是 PyInstaller
        # print('运行于 PyInstaller 绑定状态')
        print("运行于状态1")
        project_path = os.getcwd()
    else:   
        project_path = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
        if 'AppData\\Local\\Temp\\' in project_path:  # 这是 nuitka 打包时的场景
            project_path = os.getcwd()
            print('运行于状态2')
        else:
            print('运行普通状态')
    print(f'project_path: {project_path}')
    return project_path
    

def real_project_path2() -> str:
    """
    返回真实的项目目录,这是 real_project_path 函数的另一种实现
    * 程序冻结的状态下回的是可执行程序所在的目录
    * 源码情况下，返回的是项目源代码的根目录所在的目录
    程序冻结的情况下，要取得临时目录，使用 os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
    """
    if os.path.basename(sys.executable).lower().startswith("python"):  # 这是没打包
        root = os.path.dirname(os.path.dirname(os.path.realpath(__file__)))
        print("未冻结状态")
    else:
        root = os.path.dirname(os.path.realpath(sys.executable))
        print("冻结状态")
    print(f"当前项目目录： {root}")    
    return root


R_P_PATH = real_project_path2()  # 真实项目目录


def get_main_exe_md5() -> str:
    """
    返回入exe文件的md5，如果没有打包，返回None
    :param exe_real_path: 入口文件 
    :return: _description_
    """
    exe_real_path = os.path.realpath(sys.executable)
    if os.path.basename(exe_real_path).lower().startswith("python"):  # 没打包
        return None
    else:
        with open(file=exe_real_path, mode="rb") as f:
            a_str = md5(f.read()).hexdigest()
        return a_str


DEBUG = True if get_main_exe_md5() is None else False  # 全局调试开关, 关闭会节省一些资源
INCLUDE_SCHEMA = True if DEBUG else False  # 指定的 fastapi 的视频是否包含在自动文档里


def is_first_main_process(name: str = "has_started_main_process", create: bool = False, recursion: bool = False,) -> bool:
    """
    判断是不是第一个启动的主进程？ 用于在不同窗口多个进程同时启动 logger 时，判断他们是不是第一个启动的主进程
    * 如果是，那就使用 默认 logger 对象
    * 如果不是，那可能时从其他窗口单独启动的一个进程，给他的日志加一个启动文件的前缀用于区分
    这么做的目的是为了规避 loguru 在多进程下使用的文件占用的冲突
    :param name: 共享内存的名字
    :return: 共享内存对象
    """
    exited_memory = None
    try:
        exited_memory = SharedMemory(name=name, create=create, size=4)
        if not create:  # 说明之前有值
            old_value = bytearray([exited_memory.buf[x] for x in range(4)])  # 取存在的缓存的4个字节
            old_pid = struct.unpack('i', old_value)[0]
            current_pid = multiprocessing.current_process().pid
            if current_pid != old_pid:
                return False
            else:
                return True
        else:  # 之前没值，说明当前进程是第一个启动的主进程
            pid = multiprocessing.current_process().pid
            value = bytearray(struct.pack('i', pid))  # 把pid转换为16位4字节格式
            exited_memory.buf[0:4] = value
            return True
    except FileNotFoundError:
        warnings.warn("没有已启动的进程")
        if not recursion:
            return is_first_main_process(name=name, create=True, recursion=True)        
        else:
            raise e
    except Exception as e:
        raise e



def is_main_process():
    # 检查当前进程是不是主进程？
    process_name = multiprocessing.current_process().name
    is_main = True if process_name == 'MainProcess' else False
    return is_main, process_name



MD5_PREFIX = "ceLvRQFjPb8I20uSJNkDzNrv_r1i71F1oofaeO4fpqs"
MD5_SUFFIX = "esRpuJ87CLSUV8MiEaH5G_N15Gzda8WJA-JS2RDUoOc"


def generator_password(raw: str) -> str:
    """
    使用md5给字符串作摘要，生成密码
    todo: 注意,不同项目中,这个函数不通用,要和对应的前端项目的加密方式一致才行
    :param raw: 原始密码
    :return: md5加密后的
    """
    if not isinstance(raw, str):
        ms = "原始密码必须是str类型，期待一个str，得到一个{}".format(type(raw))
        raise TypeError(ms)
    else:
        return md5(f"{MD5_PREFIX}{raw}{MD5_SUFFIX}".encode(encoding="utf-8")).hexdigest().upper()
    

def get_logger(file_prefix: str = None, redirect_std: bool = False, *args, **kwargs):
    """
    返回一个loguru的实例,这个实例具备写入日志服务器的功能
    todo: 此函数应该在全局只被调用一次,否则会引起重复写入日志的情况,原因就是创建了多个 loguru的实例 
    todo: 注意,使用的过程中. 如果能删除正在使用日志文件或者目录,你必须重启项目,否则会报错或者丢失日志.
    :param file_prefix: 文件名前缀
    :param redirect_std: 是否重定向标准输出和标准错误输出,huey和celery需要用它来输出日志
    :return:
    """
    is_main_process_flag, process_name = is_main_process()
    if is_main_process_flag:  # 如果进程名显示是主进程，还要看看是不是在另外的独立窗口启动的主进程
        is_first = is_first_main_process()
        if not is_first:  # 这已经不是绝对主进程了，之前已经有其他窗口启动过主进程了。
            file_prefix =  __name__.split(".")[-1] if is_first else None
    file_prefix = "{}_".format(file_prefix) if isinstance(file_prefix, str) and len(file_prefix) > 0 else ''
    log_dir = os.path.join(R_P_PATH, "logs")
    if not os.path.exists(log_dir):
        os.makedirs(log_dir)
    info_path = os.path.join(log_dir, file_prefix + "{time:YY_MM_DD}_info.log")
    debug_path = os.path.join(log_dir, file_prefix + "{time:YY_MM_DD}_debug.log")
    error_path = os.path.join(log_dir, file_prefix + "{time:YY_MM_DD}_error.log")
    """
    add的可用参数
    rotation="500 MB"   # 文件过大就会重新生成一个文件
    rotation="12:00"    # 每天12点创建新文件
    rotation="1 week"   # 文件时间过长就会创建新文件
    retention="10 days" # 一段时间后会清空
    compression="zip"   # 保存zip格式
    enqueue=True        # 异步写入
    serialize=True      # 序列化为json
    """
    setting = {
        "rotation": "0:00",
        "retention": "15 days",
        "encoding": "utf-8",
        "colorize": False,   # 是否应将格式化消息中包含的颜色标记转换为用于终端着色的Ansi代码
        "enqueue": True,  # 要记录的消息在到达接收器之前是否应该首先通过多进程安全队列。当多进程时非常有用的。这还具有使日志调用非阻塞的优点。
        "format": "{time:YYYY-MM-DD HH:mm:ss.SSS} | {level} | {file}:{line}行 | {message}",
    }

    uru_logger = loguru.logger
    if redirect_std:
        # 添加一个新的handler，将日志的输出发送到标准输出(适用huey和celery)
        uru_logger.add(sys.stdout, format="{time} {level} {message}", level="DEBUG")
    # 添加多个handler，分别写入不同级别的日志文件
    uru_logger.add(info_path, level="INFO", **setting)
    uru_logger.add(debug_path, level="DEBUG", **setting)
    # backtrace: 使用 exception方法时输出堆栈信息; diagnose: 使用 exception方法时输出诊断信息
    uru_logger.add(error_path, level="ERROR", **setting, backtrace=True, diagnose=True)
    return uru_logger


logger = get_logger()


def get_first_second_of_day(the_date: date) -> datetime:
    """
    获取某天开始的最早时间
    :param the_date: _description_
    :return: _description_
    """
    return datetime(the_date.year, the_date.month, the_date.day)


def get_last_second_of_day(the_date: datetime) -> datetime:
    """
    获取某天结束的最后时间
    :param the_date: _description_
    :return: _description_
    """
    return datetime(the_date.year, the_date.month, the_date.day, 23, 59, 59, 999999)


def get_first_second_of_week(the_date: date) -> datetime:
    """
    获取一周的开始时间
    """
    current_week = the_date.weekday()  # 0~6
    first_day = the_date - timedelta(days=current_week)
    return datetime(first_day.year, first_day.month, first_day.day, 0, 0, 0)


def get_last_second_of_week(the_date: date) -> datetime:
    """
    获取一周的结束时间
    """
    current_week = the_date.weekday()  # 0~6
    last_day = the_date + timedelta(days=(6 - current_week))
    return datetime(last_day.year, last_day.month, last_day.day, 23, 59, 59, 999999)


def get_first_day_of_month(the_date: date, return_datetime: bool = False) -> Union[date, datetime]:
    """
    获取某月的第一天
    :param the_date: 日期
    :return:
    """
    if return_datetime:
        return datetime(the_date.year, the_date.month, 1)
    else:
        return date(the_date.year, the_date.month, 1)


def get_last_day_of_month(the_date: date, return_datetime: bool = False) -> Union[date, datetime]:
    """
    获取某月的最后一天
    :param the_date: 日期
    :return:
    """
    _first_day_week, last_day = calendar.monthrange(the_date.year, the_date.month)
    if return_datetime:
        return datetime(the_date.year, the_date.month, last_day, 23, 59, 59, 999999)
    else:
        return date(the_date.year, the_date.month, last_day)


def copy_folder(src: str, dst: str, ignore_folders: List[str] = None) -> None:
    """
    拷贝文件夹
    :param src: 源目录
    :param dst: 目标目录
    :param dst: ignore_folder 忽略的文件夹
    Kwargs:
    """
    ignore_folders = [] if ignore_folders is None else ignore_folders
    for src_name in os.listdir(src):
        src_path = os.path.join(src, src_name)
        if os.path.exists(src_path):
            dst_path = os.path.join(dst, src_name)
            if os.path.isdir(src_path):
                # 文件夹就递归
                if src_name in ignore_folders:
                    pass
                else:
                    if not os.path.exists(dst_path):
                        os.makedirs(dst_path)
                    copy_folder(src_path, dst_path, ignore_folders)
            else:
                # 文件就拷贝
                dir_path = os.path.dirname(dst_path)
                if not os.path.exists(dir_path):
                    os.makedirs(dir_path)
                try:
                    if os.path.exists(dst_path) and os.path.isfile(dst_path):
                        os.remove(dst_path)
                    shutil.copyfile(src_path, dst_path)
                    logger.info(f"拷贝文件 {src_path} 到 {dst_path} 成功")
                except Exception as e:
                    logger.info(f"拷贝文件 {src_path} 到 {dst_path} 失败")
                    logger.error(e)
                    raise e


def get_os_name() -> str:
    """
    获取操作系统类型
    :return:
    """
    return platform.system().lower()


@validate_call(config={"arbitrary_types_allowed": True})
def paging_data(data: List[dict], total: int,
                page_num: int, page_size: int = 10, show_page: int = 3) -> dict:
    """
    根据记录总数， 每页多少数据，当前第几页，和准备显示几个页码来生成前端需要渲染的数据
    是get_pages函数的继任。
    :param data: 数据
    :param total: 记录总数
    :param page_size: 每页多少数据
    :param page_num: 当前第几页
    :param show_page: 准备显示几个页码
    :return:
    """
    page_total = math.ceil(total / page_size)
    pages = [page_num]
    # 新的页码计算方式, 左右2边轮流加索引，知道满足 show_page 或者耗尽全部可用索引
    side = "left"  # 是否有左右索引供选择
    has_left, has_right = True, True  # 左右是否还有可用的索引
    while len(pages) < show_page:
        if side == "left":  # 左边加一个页码
            if has_left:
                left_index = pages[0] - 1
                if left_index < 1:
                    has_left = False
                else:
                    pages.insert(0, left_index)
            if has_right:
                side = "right"
        else:  # right
            if has_right:
                right_index = pages[-1] + 1
                if right_index > page_total:
                    has_right = False
                else:
                    pages.append(right_index)
            if has_left:
                side = "left"
        
        if (not has_right) and (not has_left):
            break


    # 旧的计算页码的方式，在接近页码最大值的时候，会导致pages的长度小于show_page
    # delta = int(show_page / 2)
    # range_left = 1 if (page_num - delta) <= 1 else page_num - delta
    # range_right = page_total if (range_left + show_page - 1) >= page_total else range_left + show_page - 1
    # pages = [x for x in range(range_left, int(range_right) + 1)]
    result = {
        "total": total,  # 记录总数
        "page_num": page_num,  # 当前页面
        "max_page": page_total,  # 最大页面数
        "pages": pages,  # 页码列表
        "list": data
    }
    return result


def get_computer_name() -> str:
    """
    获取本机计算机名
    :return: _description_
    """
    return platform.node()


def get_home_user() -> str:
    """
    获取本机用户名
    :return: _description_
    """
    return os.environ.get("USERNAME") if platform.system() == "Windows" else os.environ.get("USER")


def is_home_dev_computer() -> bool:
    """
    判断是否是本机开发环境
    :return: _description_
    """
    computer_name = get_computer_name()
    user_name = get_home_user()
    if computer_name == "DESKTOP-HPE4FAL" and user_name == 'walle':
        return True
    else:
        return False


def get_image_base64(file_path: str) -> bytes:
    """
    返回图片内容的base64编码格式
    :param file_path: 图片的相对or绝对路径, 相对路径是相对于根目录
    :return: _description_
    """
    if platform.system() == "Windows":
        image_path = pathlib.WindowsPath(file_path)
    else:
        image_path = pathlib.PosixPath(file_path)
    if os.path.isabs(image_path):
        pass
    else:
        image_path = os.path.abspath(os.path.join(os.path.dirname(os.path.realpath(__file__)), image_path))
    # logger.debug(f"当前图片路径: {image_path}")
    if os.path.exists(image_path) and os.path.isfile(image_path):
        with open(file=image_path, mode="rb") as file:
            content = file.read()
            return base64.b64encode(content)
    else:
        raise FileNotFoundError(f"无效的文件路径: {file_path}")

    
def get_now(to_str: bool = True) -> Union[datetime, str]:
    """
        获取当前时间
    :param to_str: _description_, defaults to True
    :return: _description_
    """
    now = datetime.now()
    if to_str:
        return now.strftime("%Y-%m-%d %H:%M:%S.%f")
    else:
        return now


def get_socket(ip: str = "127.0.0.1", port: int = 102):
    """
    返回一个socket.socket 
    :param ip: _description_, defaults to "127.0.0.1"
    :param port: _description_, defaults to 102
    :raises RuntimeError: _description_
    :raises RuntimeError: _description_
    :raises RuntimeError: _description_
    :raises e: _description_
    :return: _description_
    """
    try:
        cli = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        cli.connect((ip, port))
        return cli
    except Exception as e:
        if e.__class__ == ConnectionRefusedError:
            raise RuntimeError(f"连接 {ip}:{port} 失败, 连接被拒绝.")
        elif e.__class__ == OSError:
            raise RuntimeError(f"连接 {ip}:{port} 失败失败, 目标不可达")
        elif e.__class__ == socket.timeout:
            raise RuntimeError(f" {ip}:{port} 连接超时")
        else:
            raise e
        

def get_local_mac_addresses() -> dict:
    """
    获取本机的mac信息, 返回以 接口名为k，mac地址为value的字典
    """
    macs = {}
    pattern = re.compile(r"^\w{2}[\:\-]\w{2}[\:\-]\w{2}[\:\-]\w{2}[\:\-]\w{2}[\:\-]\w{2}$")
    info = psutil.net_if_addrs()
    for net_name, item_list in info.items():
        # item_list 是一个命名空间3元组，每个元素分别是 ipv4,ipv6和mac地址的信息
        print(net_name, item_list)
        for item in item_list:
            address = item.address
            if re.match(pattern, address):
                macs[net_name] = address
    return macs


def get_local_net_info() -> list:
    """
    返回本机网络信息字典组成的数组。
    :return: _description_
    """
    info = psutil.net_if_addrs()
    result = []
    for net_name, item_list in info.items():
        value = {}
        for item in item_list:
            if item.family == socket.AddressFamily.AF_INET:  # ipv4 信息
                value['ipv4'] = item.address
            elif item.family == -1:  # mac 
                mac = item.address
                mac = re.sub(r'\-', ":", mac)  # 统一为冒号分隔符
                value['mac'] = mac
            elif item.family == socket.AddressFamily.AF_INET6:  # ipv6
                value['ipv6'] = item.address
            else:
                raise ValueError(f"未知的类型： {item.__class__.__name__}")
        value['name'] = net_name
        result.append(value)
    return result


def check_env_old() -> str:
    """
    使用系统和网络信息检测运行环境 废止，新版改用Ip检测
    :return: _description_
    """
    env = "dev"
    if platform.system() == "Windows":
        pass
    else:
        addresses = get_local_mac_addresses()
        logger.debug(addresses)
        env = "uat"
    return env


def check_env() -> str:
    """
    使用系统信息和环境变量检测运行环境
    :return: _description_
    """
    env = "dev"
    if platform.system() == "Windows":
        pass
    else:
        env = os.getenv("WORKENV", "uat")  # 在Linux 下变量名是区分大小写
    return env



def decimal_to_base(decimal_num: int, base: int = 36) -> str:
    """
    将十进制数转换为指定进制的字符串表示形式
    :param decimal_num: 待转换的数字
    :param base: 进制, defaults to 36
    """
    if base < 2 or base > 36:
        raise ValueError("基数必须在 2 到 36 之间")
    digits = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
    if decimal_num == 0:
        return digits[0]
    result = ""
    while decimal_num > 0:
        decimal_num, remainder = divmod(decimal_num, base)
        result = digits[remainder] + result
    return result


def generator_sn(now: datetime = None, prefix: str = "", index: int = 0) -> str:
    """
    生成序列号
    :param now: 当前时间, defaults to None
    :param index: 序列号索引, defaults to 0
    :return: _description_
    """
    if now is None:
        now = datetime.now()
    decimal_num = int(now.strftime("%y%m%d%H%M%S%f") + str(index % 100).zfill(2))
    sn = prefix + decimal_to_base(decimal_num, base=36)
    return sn


def quantize_decimal(value: Decimal, places: int = 2, rounding: str = None) -> None | Decimal:
    """
    对数字进行四舍五入
    :param value: 数字
    :param places: 保留小数位数
    :param rounding: 舍入方法, defaults to None
    :return: 四舍五入后的数字
    """
    value = Decimal(str(value)) if not isinstance(value, Decimal) else value
    rounding = rounding or decimal.ROUND_HALF_EVEN  # 舍入的方法 默认是 ROUND_HALF_EVEN 银行家算法
    d = "1.".ljust(2 + places, "0")
    if value is None:
        return None 
    return value.quantize(Decimal(d), rounding=rounding)


def test_decimal():
    # 测试小数转化
    rounding_list = [
        decimal.ROUND_HALF_DOWN,  # 舍入到最接近的数，同样接近则舍入方向为零
        decimal.ROUND_HALF_EVEN, # 舍入到最接近的数，同样接近则舍入到最接近的偶数。
        decimal.ROUND_HALF_UP, # 舍入到最接近的数，同样接近则舍入到零的反方向。
    ]
    for num in range(10000, 90000):
        num = num / 10000
        values = [quantize_decimal(num, 2, y) for y in rounding_list]
        s = set(values)
        if len(s) > 1:
            m = dict(zip(rounding_list, values))
            print(num, m)


def markdown(file_name: str) -> str:
    """
    将markdown转换为html
    :param file_name: descriptions 目录下的文件名，不含后缀
    :return: html内容
    """
    file_path = os.path.join(R_P_PATH, 'descriptions', file_name + '.md')
    content = ''
    with open(file_path, 'r', encoding='utf-8') as f:
        content = f.read()
    return mistune.html(content)
    

if __name__ == "__main__":
    get_first_second_of_week(date(24,8,15))
    test_decimal()
    # 生成唯一序号
    now = datetime.now()
    for x in range(10):
        print(generator_sn(now, str(x)))
    print(generator_password("123456"))
    pass